An exploration of nighttime lights and the Dark Sky Movement in the US

Author

Carly Staebell

Published

December 4, 2025

Introduction

Electric lights have benefited society immensely since their invention in 1879. Modern work, recreation, and transportation are made possible largely due to artificial lighting. However, when artificial outdoor lighting becomes inefficient and unnecessary, it is known as light pollution, with negative impacts on both wildlife and humans [1].

Concerns about the impacts of light pollution on observational astronomy led to the 1988 formation of the nonprofit now known as DarkSky International [2]. Since then, DarkSky has grown into a recognized global authority on light pollution with a mission to safeguard communities and wildlife against light pollution [3]. The organization’s International Dark Sky Places program allows communities, parks, and protected areas to seek certification for efforts to preserve and protect their dark skies [4]. While many communities implement outdoor lighting policies without seeking recognition, a DarkSky certification is an indicator of places that have made dark sky conservation a priority.

This project aims to explore trends in nighttime light levels first from a national contiguous US perspective, and through more detailed case studies of individual cities. Three DarkSky certified communities [4] with different sizes and designation dates have been selected:

  • Flagstaff, Arizona: Population ~77,000, designated 2001;
  • Homer Glen, Illinois: Population ~25,000, designated 2011;
  • Bee Cave, Texas: Population ~9,000, designated 2023.

These three communities will each be paired with a community of similar size that has fewer, if any, ordinances controlling light pollution. Each community pair will be examined to see if there are observable differences in nighttime light emissions.

Materials and methods

The following data were used.

  • NASA’s Black Marble annual nighttime lights (VNP46A4 data product) [5] sourced via the blackmarbler package [6]
    • Spatial resolution: 500 m
  • US Census Bureau TIGER data [7] sourced via the tigris package [8]
  • US Census Bureau American Community Survey (ACS) population data [9] sourced via the tidycensus package [10]
  • MODIS Type 1 yearly land use/land cover (MCD12Q1 data product) sourced via AppEEARS [11]
    • Spatial resolution: 500 m
Code
library(tidyverse)
library(leaflet)
library(kableExtra)
library(htmlwidgets)
library(widgetframe)
knitr::opts_chunk$set(widgetframe_widgets_dir = 'widgets' ) 
knitr::opts_chunk$set(cache=TRUE)  # cache the results for quick compiling

library(blackmarbler)
library(sf)
library(terra)
library(tigris)
library(tidyterra)
library(tidycensus)
library(patchwork)
library(piggyback)
library(ggiraph)
library(scales)

Download and clean all required data

National and statewide nighttime lights

A US Census TIGER shapefile was downloaded to provide geographic state boundaries. A separate script was used to download and save annual nighttime lights data for the contiguous US (‘black_marble_data_download.R’). Raster data was downloaded for plotting purposes, and extracted mean nighttime lights values were also downloaded for analysis. Nighttime lights are radiance values with units of \(\frac{n W}{cm^2 sr}\).

Code
# Define national spatial extent
nonconus <- c("Guam", "Hawaii", "Alaska",
              "Commonwealth of the Northern Mariana Islands",
              "United States Virgin Islands", "American Samoa", "Puerto Rico")

states_sf <- states() |>
  filter(!NAME %in% nonconus) |>
  select(NAME, geometry) |>
  st_transform(crs = "epsg:4326")

# Dissolve individual state boundaries to get CONUS outline
conus <- st_union(states_sf)|>
  st_as_sf()

## Read in national raster data for 2024 (to be used for leaflet map later)
pb_download("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t2024.tif", 
            repo = "cstaebell/final-project-cstaebell")

us_2024_rast <- rast("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t2024.tif") |>
  mask(conus)

# create log transformed version for plotting
log_us_2024 <- app(us_2024_rast, fun = log1p)

# Downsample log data for map (commented out since file written)
#aggregate(log_us_2024, fact = 4, fun = "median", filename = "data/log_us_2024_downsampled.tif", overwrite = TRUE)

#---
# Download and process annual US mean NTL data
#---
years = 2014:2024
conus_means = numeric(length(years))

for (i in 1:length(years)) {
  pb_download(paste("CONUS_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = ""), repo = "cstaebell/final-project-cstaebell")
  annual_mean <- readRDS(paste("CONUS_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = "")) |>
    pull(ntl_mean)
  
  conus_means[i] <- annual_mean
}

conus_df <- data.frame(year = years, mean_ntl = conus_means)

#---
# Download and process mean NTL data for states
#---
state_means <- matrix(nrow = 49, ncol = length(years))

# Read in annual data for states and create a matrix of state means
for (i in 1:length(years)) {
  pb_download(paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = ""), repo = "cstaebell/final-project-cstaebell")
  annual_mean <- readRDS(paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_mean_t",
                             years[i], ".Rds", sep = ""))
  
  state_means[,i] <- annual_mean[,2]

  # check that state names are ordered the same way for each year
  if (i == 1) {
    state_names <- annual_mean[,1]
  } else {
    if (sum(state_names != annual_mean[,1]) != 0) {
      print("Different state names")
    }
  }
}

# Create data frame of state means and add percent change column
state_means_df <- data.frame(state_means)
colnames(state_means_df) <- paste("ntl_", years, sep = "")
state_means_df <- state_means_df |>
  mutate(state = state_names, 
         pct_change = (ntl_2024 - ntl_2014) / ntl_2014 * 100)


# Combine state NTL data with geographic data
states_ntl <- states_sf |>
  inner_join(state_means_df, by = c("NAME" = "state"))

Case Studies

For case study analyses, population data and local geographies for each Dark Sky Place were downloaded from the US Census.

Code
# Download population data
# US population to search for companion cities
us_pop <- get_acs(geography = "place", state = NULL, 
                  variables = "B01003_001", key = census_api_key)

# State populations for the states in which the selected DarksKy communities reside
az_pop <- get_acs(geography = "place", state = "AZ", 
                         variables = "B01003_001", key = census_api_key)

il_pop <- get_acs(geography = "place", state = "IL", 
                         variables = "B01003_001", key = census_api_key)

tx_pop <- get_acs(geography = "place", state = "TX", 
                         variables = "B01003_001", key = census_api_key)

# Download local geographies: selected Dark Sky Places
flagstaff <- places(state = "AZ", cb = TRUE, year = 2024) |>
  filter(NAME == "Flagstaff") |>
  st_transform(crs = "epsg:4326") |>
  left_join(us_pop, by = "GEOID")

homerglen <- places(state = "IL", cb = TRUE, year = 2024) |>
  filter(NAME == "Homer Glen") |>
  st_transform(crs = "epsg:4326") |>
  left_join(us_pop, by = "GEOID")

beecave <- places(state = "TX", cb = TRUE, year = 2024) |>
  filter(NAME == "Bee Cave") |>
  st_transform(crs = "epsg:4326") |>
  left_join(us_pop, by = "GEOID")

Candidate cities to pair with each selected Dark Sky community were found by filtering US ACS population data to return a listing of municipalities with populations within 1% of each Dark Sky community population. Final comparison cities were selected based on population match and state. Preference was given to cities within states that are not known to have laws regarding light pollution or dark sky protection, as compiled by Schultz [12].

Code
# Get listing of suitable comparison cities
flagstaff_sim_pop <- us_pop |> filter(estimate >= (flagstaff$estimate - 0.005*flagstaff$estimate) 
                                & estimate <= (flagstaff$estimate + 0.005*flagstaff$estimate)) |>
  separate(NAME, into = c("city", "state"), sep = ", ")

homerglen_sim_pop <- us_pop |> filter(estimate >= (homerglen$estimate - 0.005*homerglen$estimate) 
                                & estimate <= (homerglen$estimate + 0.005*homerglen$estimate)) |>
  separate(NAME, into = c("city", "state"), sep = ", ")

beecave_sim_pop <- us_pop |> filter(estimate >= (beecave$estimate - 0.005*beecave$estimate) 
                                & estimate <= (beecave$estimate + 0.005*beecave$estimate)) |>
  separate(NAME, into = c("city", "state"), sep = ", ")

# Download/process comparison city data
 # Flagstaff counterpart
scranton <- places(state = "PA", cb = TRUE, year = 2024) |>
  filter(NAME == "Scranton") |>
  left_join(us_pop, by = "GEOID") |>
  st_transform(crs = "epsg:4326")

 # Homer Glen counterpart
belvidere <- places(state = "IL", cb = TRUE, year = 2024) |>
  filter(NAME == "Belvidere") |>
  left_join(us_pop, by = "GEOID") |>
  st_transform(crs = "epsg:4326")

 # Bee Cave counterpart
clanton <- places(state = "AL", cb = TRUE, year = 2024) |>
  filter(NAME == "Clanton") |>
  left_join(us_pop, by = "GEOID") |>
  st_transform(crs = "epsg:4326")

The final city pairs are as follows:

Pair Community Population
A Flagstaff, AZ 76,333
A Scranton, PA 76,074
B Homer Glen, IL 24,516
B Belvidere, IL 24,510
C Bee Cave, TX 8,861
C Clanton, AL 8,862

The annual US nighttime lights rasters were read in and subsetted to create a raster and dataframe of mean values for each city. Since simply looking at annual means for each city may be more of a proxy for overall development than for lighting policy, land use/land cover data1 were also brought in to help make comparisons across communities more meaningful. The land cover rasters were cropped to each city, and then the urban areas were isolated, allowing for calculation of annual mean urban nighttime lights.

Code
# Nighttime lights and land cover data are in a single chunk to avoid external pointer errors when rendering

#---
# Nighttime lights
#---
# Subset nighttime lights rasters to each city
cities <- list(flagstaff, homerglen, beecave, scranton, belvidere, clanton)
city_rasters <- vector("list", length = length(cities))

# Load files for each year
for (i in 1:length(years)){
  pb_download(paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t", 
                     years[i], ".tif", sep = ""), repo = "cstaebell/final-project-cstaebell")
  rast_file <- paste("US_VNP46A4_NearNadir_Composite_Snow_Free_qflag_t", 
                     years[i], ".tif", sep = "")
  
  year_rast <- rast(rast_file)
  
  # Loop over cities
  for (j in 1:length(cities)) {
    # Crop and mask to city extent
    current_rast <- year_rast |>
      terra::crop(cities[[j]]) |>
      mask(cities[[j]])
    
    # Create list entry during first iteration, then append in subsequent iterations
    if (i == 1) {
    city_rasters[[j]] <- list(current_rast)
    } else {
    city_rasters[[j]][[length(city_rasters[[j]]) + 1]] <- current_rast
    }
  }
}

# Stack city lists 
city_rasters <- lapply(city_rasters, rast)

# Create separate rasters for each city 
city_var_names <- paste0(c("flagstaff", "homerglen", "beecave", "scranton", 
                           "belvidere", "clanton"), "_rast")
for (c in 1:length(city_var_names)) {
  assign(city_var_names[c], city_rasters[[c]])
}

# Calculate annual means
annual_means <- vector("list", length = length(cities))
for (i in 1:length(city_rasters)) {
  ntl_vals <- data.frame(values(city_rasters[[i]])) |>
    summarize(across(t2014:t2024, ~ mean(.x, na.rm = TRUE)))
  
  annual_means[[i]] <- ntl_vals
}

# Create mean dataframes for each city
city_df_var_names <- paste0(c("flagstaff", "homerglen", "beecave", "scranton", 
                           "belvidere", "clanton"), "_means")
for (i in 1:length(annual_means)) {
  df <- annual_means[[i]] |>
    pivot_longer(cols = t2014:t2024) |>
    mutate(name = years) |>
    rename(year = name, mean_ntl = value)
  
  assign(city_df_var_names[i], df)
}

#---
# Land cover data
#---
# Download land cover data 
pb_download("MCD12Q1.061_LC_Type1_doy2019001000000_aid0001.tif", 
            repo = "cstaebell/final-project-cstaebell")
pb_download("MCD12Q1.061_LC_Type1_doy2020001000000_aid0001.tif", 
            repo = "cstaebell/final-project-cstaebell")
land_use_rast_2019 <- rast("MCD12Q1.061_LC_Type1_doy2019001000000_aid0001.tif")
land_use_rast_2020 <- rast("MCD12Q1.061_LC_Type1_doy2020001000000_aid0001.tif")

# Subset land cover rasters to each city
#city_ntl_rasters <- list(flagstaff_rast, homerglen_rast, beecave_rast, scranton_rast, 
#                          belvidere_rast, clanton_rast)
land_use_rasters <- vector("list", length = length(cities))
urban_ntl_dfs <- vector("list", length = length(cities))

for (i in 1:length(cities)) {
  # Specify which data to use (2019 for all cities but Clanton)
  if (i == 6) {
    land_use_rast <- land_use_rast_2020
  } else {
    land_use_rast <- land_use_rast_2019
  }
  
  # Crop and mask to city extent
  current_rast <- land_use_rast |>
    terra::crop(cities[[i]]) |>
    mask(cities[[i]])
  # Save to list for future use 
  land_use_rasters[[i]] <- current_rast
  
  # Mask all but urban land use (land use code = 13)
  urban_area <- current_rast
  urban_area[urban_area != 13] <- NA
  city_urban <- city_rasters[[i]] |>
    mask(urban_area) 
  
  # Calculate mean NTL for urban areas 
  urban_ntl_dfs[[i]] <- data.frame(values(city_urban)) |>
    summarize(across(t2014:t2024, ~ mean(.x, na.rm = TRUE))) |>
    pivot_longer(cols = t2014:t2024) |>
    mutate(name = years) |>
    rename(year = name, mean_urban_ntl = value)
}

# Create separate dataframes from list object
urban_ntl_var_names <- paste0(c("flagstaff", "homerglen", "beecave", "scranton", 
                           "belvidere", "clanton"), "_urban_ntl")

for (i in 1:length(urban_ntl_dfs)) {
  assign(urban_ntl_var_names[i], urban_ntl_dfs[[i]])
}


# Prepare data for sample land cover plots
# Define land cover encoding
Land_Cover_Type_1 <- c(
  "Water" = 0, "Evergreen Needleleaf forest" = 1, "Evergreen Broadleaf forest" = 2,
  "Deciduous Needleleaf forest" = 3, "Deciduous Broadleaf forest" = 4, "Mixed forest" = 5,
  "Closed shrublands" = 6, "Open shrublands" = 7, "Woody savannas" = 8, "Savannas" = 9,
  "Grasslands" = 10, "Permanent wetlands" = 11, "Croplands" = 12, "Urban & built-up" = 13,
  "Cropland/Natural vegetation mosaic" = 14, "Snow & ice" = 15, "Barren/Sparsely vegetated" = 16
)

lcd <- data.frame(
  ID = Land_Cover_Type_1,
  landcover = names(Land_Cover_Type_1),
  stringsAsFactors = FALSE
)

# Create data frames for plotting
scranton_lulc <- as.data.frame(land_use_rasters[[4]], xy = TRUE) |>
  left_join(lcd, by = join_by(MCD12Q1.061_LC_Type1_doy2019001000000_aid0001 == ID))

# Since Scranton has more landcover types, use its types to join with Flagstaff for a consistent color scheme
lcd_reduced <- lcd |>
  filter(landcover %in% unique(scranton_lulc$landcover))
  
flagstaff_lulc <- as.data.frame(land_use_rasters[[1]], xy = TRUE) |>
  right_join(lcd_reduced, by = join_by(MCD12Q1.061_LC_Type1_doy2019001000000_aid0001 == ID))

Finally, paired data frames were created from the processed data to facilitate plotting and visualizations.

Code
# Annual means
# Pair A: Flagstaff and Scranton
flagstaff_means_label <- flagstaff_means |>
  mutate(city = "Flagstaff", pair = "A", group = "Dark Sky")
scranton_means_label <- scranton_means |>
  mutate(city = "Scranton", pair = "A", group = "Comparison")
pair_a_means <- bind_rows(flagstaff_means_label, scranton_means_label)

# Pair B: Homer Glen and Belvidere
homerglen_means_label <- homerglen_means |>
  mutate(city = "Homer Glen", pair = "B", group = "Dark Sky")
belvidere_means_label <- belvidere_means |>
  mutate(city = "Belvidere", pair = "B", group = "Comparison")
pair_b_means <- bind_rows(homerglen_means_label, belvidere_means_label)

# Pair C: Bee Cave and Clanton
beecave_means_label <- beecave_means |>
  mutate(city = "Bee Cave", pair = "C", group = "Dark Sky")
clanton_means_label <- clanton_means |>
  mutate(city = "Clanton", pair = "C", group = "Comparison")
pair_c_means <- bind_rows(beecave_means_label, clanton_means_label)

all_pair_means <- bind_rows(pair_a_means, pair_b_means, pair_c_means)
pair_names <- c("Flagstaff & Scranton", "Homer Glen & Belvidere", "Bee Cave & Clanton")
names(pair_names) <- c("A", "B", "C")

## Urban means
# Pair A: Flagstaff and Scranton
flagstaff_urban_label <- flagstaff_urban_ntl |>
  mutate(city = "Flagstaff", pair = "A", group = "Dark Sky")
scranton_urban_label <- scranton_urban_ntl |>
  mutate(city = "Scranton", pair = "A", group = "Comparison")
pair_a_urban <- bind_rows(flagstaff_urban_label, scranton_urban_label)

# Pair B: Homer Glen and Belvidere
homerglen_urban_label <- homerglen_urban_ntl |>
  mutate(city = "Homer Glen", pair = "B", group = "Dark Sky")
belvidere_urban_label <- belvidere_urban_ntl |>
  mutate(city = "Belvidere", pair = "B", group = "Comparison")
pair_b_urban <- bind_rows(homerglen_urban_label, belvidere_urban_label)

# Pair C: Bee Cave and Clanton
beecave_urban_label <- beecave_urban_ntl |>
  mutate(city = "Bee Cave", pair = "C", group = "Dark Sky")
clanton_urban_label <- clanton_urban_ntl |>
  mutate(city = "Clanton", pair = "C", group = "Comparison")
pair_c_urban <- bind_rows(beecave_urban_label, clanton_urban_label)

all_pair_urban <- bind_rows(pair_a_urban, pair_b_urban, pair_c_urban)

Results

Case Studies

Code
fs_coord <- st_centroid(flagstaff) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Flagstaff", pair = "A", group = "Dark Sky")

sc_coord <- st_centroid(scranton) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Scranton", pair = "A", group = "Comparison")

hg_coord <- st_centroid(homerglen) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Homer Glen", pair = "B", group = "Dark Sky")

bv_coord <- st_centroid(belvidere) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Belvidere", pair = "B", group = "Comparison")

bc_coord <- st_centroid(beecave) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Bee Cave", pair = "C", group = "Dark Sky")

cl_coord <- st_centroid(clanton) |>
  st_coordinates() |>
  data.frame() |>
  mutate(city = "Clanton", pair = "C", group = "Comparison")

markers_df <- bind_rows(fs_coord, sc_coord, hg_coord, bv_coord, bc_coord, cl_coord)

log_us_2024_ds <- rast("data/log_us_2024_downsampled.tif")

leaflet() |>
  addProviderTiles("CartoDB") |>
  addRasterImage(log_us_2024_ds, group = "US NTL") |>
  addMarkers(data = markers_df, ~X, ~Y, label = ~as.character(city))

Four of the six case study cities exhibit an increasing trend similar to that of the national mean. However, the Pair A cities, Flagstaff and Scranton, show decreasing tendencies. Also notable from this plot are the large differences in radiance values within pairs, despite controlling for population during the city selection process.

Code
# Create plot
ggplot(data = all_pair_means) +
  geom_line(aes(x = year, y = mean_ntl, color = pair, linetype = group), linewidth = 1) +
  facet_wrap(~ pair, labeller = labeller(pair = pair_names)) +
  scale_linetype_manual(values = c("Dark Sky" = "solid", "Comparison" = "dashed")) +
  scale_color_manual(values = c("A" = "firebrick", "B" = "royalblue3", "C" = "green4")) +
  labs(x = "Year",
       y = expression("Mean Radiance (nW cm"^-2~"sr"^-1*")"),
       title = "Mean Annual Radiance",
       linetype = "City Type",
       color = "Pair") +
  theme_light() +
  theme(strip.text.x = element_text(color = "black"),
        plot.title = element_text(hjust = 0.5))

A quick look at the land use for Flagstaff compared to Scranton helps to explain the vast difference in mean nighttime lights between the two cities. Flagstaff contains much more undeveloped land like grassland and savannas, whereas most of Scranton is classified as urban and built up.

Code
fs_lulc_plot <- ggplot(flagstaff_lulc, aes(x = x, y = y)) +
  geom_tile(aes(fill = landcover)) +
  coord_quickmap() +
  theme_light() +
  scale_fill_viridis_d() +
  labs(x = NULL,
       y = NULL,
       title = "Flagstaff Land Cover",
       fill = NULL)
  
sc_lulc_plot <- ggplot(scranton_lulc, aes(x = x, y = y)) +
  geom_tile(aes(fill = landcover)) +
  coord_quickmap() +
  theme_light() +
  scale_fill_viridis_d() +
  labs(x = NULL,
       y = NULL,
       title = "Scranton Land Cover",
       fill = NULL) +
  scale_x_continuous(breaks = seq(-75.7, -75.6, by = 0.05))

fs_lulc_plot + sc_lulc_plot + plot_layout(guides = "collect") & theme(legend.position = "bottom")
Warning: Removed 3 rows containing missing values or values outside the scale range
(`geom_tile()`).

Given the imbalances in land cover between different cities, restricting analysis to urban areas seems to be a more effective means of comparison. (Proceeding this way introduces an assumption that any lighting policy effects outside of non-built-up areas are dwarfed by those on urban areas.) From the plots of urban mean radiance, we can see that the radiance for the Dark Sky Places is generally lower than for their paired cities.

Code
ggplot(data = all_pair_urban) +
  geom_line(aes(x = year, y = mean_urban_ntl, color = pair, linetype = group), linewidth = 1) +
  facet_wrap(~ pair, labeller = labeller(pair = pair_names)) +
  scale_linetype_manual(values = c("Dark Sky" = "solid", "Comparison" = "dashed")) +
  scale_color_manual(values = c("A" = "firebrick", "B" = "royalblue3", "C" = "green4")) +
  labs(x = "Year",
       y = expression("Mean Radiance (nW cm"^-2~"sr"^-1*")"),
       title = "Mean Annual Radiance from Urban Areas",
       linetype = "City Type",
       color = "Pair") +
  theme_light() +
  theme(strip.text.x = element_text(color = "black"),
        plot.title = element_text(hjust = 0.5))

Discussion of lighting policies

Use DarkSky certification application packets?

Flagstaff’s Lighting Ordinance aims to encourage lighting practices and systems that will minimize light pollution and trespass, balance the conservation of energy with the maintenance of nighttime safety and productivity, and reduce degradation of the nighttime visual environment [13]. Low-pressure sodium (LPS) lamps or narrow-spectrum amber LEDs are required in all Class 2 Lighting Areas. Decorative lighting must be included in the total lumen calculations for the site. Restrictions on total outdoor light output vary by zone and land use type (commercial, industrial, and multifamily residential vs single-family residential). Shielded light fixtures can be used to reduce contribution to total calculation. Shielding and proper aiming is also required to confine direct illumination from light fixtures to the property boundaries of the source (light trespass standard). Time limits for outdoor lighting - 9 or 11 pm or no later than 30 min after business closes. Street lighting standards in City Engineering Standards, Title 12. Holiday decorations - “low voltage holiday decorations may be unshielded and remain on all night from November 15 to January 15.” Standards for internally illuminated signs – cannot have a light background

Conclusions

Nighttime light emissions from the contiguous US show an increasing trend. Some states show drastic growth in radiance from 2014-2024, while others, particularly in the Midwest and Northeast, show more moderate changes.

After controlling for population and urban area, the nighttime lights of DarkSky-certified communities appear lower than those of similar cities that have not made outdoor lighting policy a priority. Some consistent elements across the Dark Sky community policies are XYZ. Many factors determine nighttime lights, and there is no proof that lighting policy can completely explain the observed differences, but it is encouraging to see the generally lower emissions from Dark Sky places.

Limitations and further opportunities:

  • Radiance of nighttime lights is not a direct measure of light pollution. Some cities are switching from orange high pressure sodium lamps to white LEDs. VIIRS spectral range does not completely cover the blue light peak of many modern white LEDs, which can lead to somewhat misleading decreases in satellite-observed radiance [X].
  • Cities were paired based on same population as of 2023 ACS census. It is likely that the populations were not the same for all other years due to different rates of population growth.
  • One year of land cover data was assumed to represent each city’s land cover over the 10-year span, which is a simplification. Future analysis could incorporate a more nuanced use of land cover data.
  • Similarly, annual population data could be incorporated to further explore the relationship between nighttime lights and population.
  • Beyond this exploratory analysis, statistical tests could be used to quantify differences between cities. Analysis could also be expanded to different cities.

References

[1] Chepesiuk, R. (2009). Missing the dark: health effects of light pollution. Environmental Health Perspectives, 117(1), A20+. https://www.jstor.org/stable/40066663.

[2] Hunter, T., (2023). The birth of DarkSky (IDA) and a lifelong mission fighting light pollution. DarkSky. https://darksky.org/news/the-birth-of-ida-and-a-lifelong-mission-fighting-light-pollution/.

[3] DarkSky International. (2025a). About DarkSky. https://darksky.org/about/#mission.

[4] DarkSky International (2025b). International Dark Sky Places. https://darksky.org/what-we-do/international-dark-sky-places/.

[5] Román, M. O., Wang, Z., Sun, Q., Kalb, V., Miller, S. D., Molthan, A., Schultz, L., Bell, J., Stokes, E. C., Pandey, B., Seto, K. C., Hall, D., Oda, T., Wolfe, R. E., Lin, G., Golpayegani, N., Devadiga, S., Davidson, C., Sarkar, S., Praderas, C., Schmaltz, J., Boller, R., Stevens, J., Ramos González, O. M., Padilla, E., Alonso, J., Detrés, Y., Armstrong, R., Miranda, I., Conte, Y., Marrero, N., MacManus, K., Esch, T., Masuoka, E. J. (2018). NASA’s Black Marble nighttime lights product suite. Remote Sensing of Environment, 210: 113-143, https://doi.org/10.1016/j.rse.2018.03.017.

[6] Marty, R., Vicente, G.S. (2025). Package ‘blackmarbler’. https://cran.r-project.org/web/packages/blackmarbler/blackmarbler.pdf.

[7] U.S. Census Bureau. 2024. 2024 Census TIGER/Line Shapefiles. https://data.census.gov

[8] Walker, K. , Rudis, B. (2025). Tigris: Load Census TIGER/Line shapefiles. R package version 2.2.1. https://cran.r-project.org/web/packages/tigris/index.html

[9] U.S. Census Bureau. (2023). Total Population.  American Community Survey 5-Year Estimates Detailed Tables. Table B01003.

[10] Walker, K., Herman, M. (2025). tidycensus: Load US Census Boundary and Attribute Data as ‘tidyverse’ and ‘sf’-Ready Data Frames. R package version 1.7.3, https://walker-data.com/tidycensus/.

[11] Friedl, M., Sulla-Menashe, D. (2022). MODIS/Terra+Aqua Land Cover Type Yearly L3 Global 500m SIN Grid V061. NASA EOSDIS Land Processes Distributed Active Archive Center. Accessed 2025-11-29 from https://doi.org/10.5067/MODIS/MCD12Q1.061. Accessed November 29, 2025.

[12] Schultz, J. (2022). States shut out light pollution. National Conference of State Legislatures. https://www.ncsl.org/environment-and-natural-resources/states-shut-out-light-pollution

[13] Flagstaff Lighting Ordinance (finish citation)

[14] Homer Glen Lighting Ordinance (finish citation)

[X] Stare, Jurij; Kyba, Christopher (2025): Radiance Light Trends. GFZ Data Services. https://doi.org/10.5880/GFZ.1.4.2019.001.

Footnotes

  1. Although land cover changes over the period from 2014 to 2024, for the purposes of this project, only one year of land cover data was considered for each community. Data availability is one reason for this simplification – annual data is not available for all study areas for all years, and the MODIS science team urges caution when using land cover layers for 2021 and beyond due to lack of updates to the classification training database [11]. Therefore, 2019 data was used for all cities except for Clanton, which used 2020 data.↩︎